Skip to content

bun:test: fixtures and around hooks wait on what await waits on, through the engine - #47800

Open
robobun wants to merge 3 commits into
robobun/871123bb/thenable-before-drainfrom
robobun/871123bb/await-through-engine
Open

robobun wants to merge 3 commits into
robobun/871123bb/thenable-before-drainfrom
robobun/871123bb/await-through-engine

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A test.extend fixture and an around hook read then from what their function returns and call it (test_context_fixtures.rs:703, test_context_around.rs:172). A replaced Promise.prototype.then gets that call. While then is not callable, the run fails: Fixture "x" returned without calling use() or AroundHookSetupError.
  • An around hook calls then() of a returned thenable at once and does not follow a second thenable. Vitest 5.0.3 waits as await does.

Fix

Background

  • test.extend sets up a fixture value for a test. An around hook (aroundEach, aroundAll) wraps a test or a suite. Both are from bun test: Vitest compatibility #44833, in no release.
  • then_with_value registers two native reactions on a promise. It does not read then.
  • Weighed: keep the then read with a guard for a then that is not callable. A replaced then() still runs.

Downsides

  • An onCleanup callback that throws after its test ended is now an uncaughtException, not an unhandledRejection.
  • An around hook's thenable gets its then() call one microtask turn later.
  • Measured (release): fixture set-up 10622 to 8516 instructions, around hook set-up 9392 to 7320. Binary -1024 B.
Notes

Order and scope

Behaviour changes

Each row was run on release builds of main (3f3a9ff), of #47760 (55b02d8) and of this branch. "Function" means a fixture function or an around hook.

Case main #47760 this PR
The function returns a native promise while Promise.prototype.then is replaced the replaced then() is called once the same not called
The same while then is not callable Fixture "x" returned without calling use(), AroundHookSetupError the same the fixture and the hook work
A builder fixture function returns a thenable while Promise.prototype.then is not callable the fixture gets the value the fixture gets the thenable the fixture gets the value
An around hook returns a thenable: the turn of its then() call at once at once one microtask turn later, as await and Vitest 5.0.3
The thenable of an around hook resolves with a second thenable not followed. A rejection of it is lost. A hook that calls runTest() in it fails with AroundHookSetupError the same followed, as Vitest 5.0.3
A fixture function returns a native promise whose own then is not callable the fixture gets the promise object the same the fixture gets its value, as await and Vitest 5.0.3
A fixture function returns a Promise subclass whose own then is not callable the fixture gets the promise object the same the same
An onCleanup callback throws when its fixture function settles after the test ended unhandledRejection the same uncaughtException
An async onCleanup callback rejects then unhandledRejection the same the same

Not changed by this step: a thenable from a fixture function gets one then() call, one turn later (#47760). A Promise subclass with its own then() is awaited through that then() at both sites, as on main.

The route of an error

  • The old attach called then(), which made a derived promise. An error that left a reaction rejected that promise, and nothing handled it.
  • then_with_value makes no derived promise. The error is thrown out of the reaction job. It is reported when it is thrown, and the entry that runs then gets it.
  • What reaches this: a synchronous throw of an onCleanup callback when teardown runs at once. That is when the fixture function settles after its test ended, or after its file ended for a file scope. The other errors that can leave the two fixture reactions take the same route. The reactions of an around hook call no user code.
  • With only a process.on("unhandledRejection") listener, main gives that error to the listener and the running test passes. This PR fails the running test with it. With only an uncaughtException listener it is the reverse.
  • The new test pins the event with both listeners. A better report (one that blames no test) belongs in provide(). It is not part of this step.

Not changed here

  • vi.waitFor, vi.waitUntil and the callback of expect.poll (src/runtime/test_runner/vi_wait.rs:324) keep the then read and the direct call. Vitest 5.0.3 makes the same call in waitFor and waitUntil (thenable.then(...) in its dist). A then that is replaced or not callable still reaches these three. After this PR it is the only read of then in src/runtime/test_runner.
  • The rule of the runner for a value that a test or a hook returns. That is bun:test: wait on a Promise subclass through its own then() #41008.
  • An around hook still ignores the value that its promise resolves with.

Implementation

  • A value that await does not wait on is passed on, as before. awaited_promise returns None for a value that is not an object. It also returns None when PromiseResolve made a new promise that is fulfilled at once: the value has no callable then. Otherwise it returns the promise: the value itself when its constructor is the Promise of the realm, or a new promise that follows then().
  • The reactions take (value, wrapper). The four entries in test_context.bind.ts no longer pass this.
  • The fixture site caches the promise that it waits on. The teardown entry gives it to the runner, which calls no then() on a native promise.
  • JSPromise::promise_resolve wraps the existing binding of JSC::JSPromise::resolvedPromise. VirtualMachine.rs had the one private declaration of it, and now calls the wrapper. There is no new C++.

Measurements

Release builds of #47760 and of this branch from one build directory, linux-x64, JIT off. Method: a gdb single-step from the entry of the function to its return, at the fifth call.

  • Instructions inside Bun__ActiveFixture__setUp, the call of the fixture function included: async fixture function 10622 to 8516. A function that returns a thenable: about 10800 to about 8600. That pair moves by up to 70 between runs.
  • Instructions inside Bun__AroundHook__setUp, the call of the hook included: async hook 9392 to 7320, a hook that returns a thenable 8468 to 7686.
  • Each site no longer makes two bound functions and the promise that then() derives.
  • size text: 140288367 to 140287343, -1024 B. run_test_callback is 2489 B on both. New JSPromise::promise_resolve: 109 B.
  • perf and valgrind are not in the container, so there is no cache or branch number.

Tests

All in test/js/bun/test/vitest-flavor.test.ts:

  • "the promise of a fixture function is awaited without a then() call": Promise.prototype.then is replaced, or not callable, while the runner attaches.
  • "the promise of a hook is awaited without a then() call": the same for aroundEach and aroundAll.
  • "a promise whose own then is not callable is awaited or passed on, as await does".
  • "a fixture function that settles after its test has ended runs its cleanup right away": both process listeners log the event.
  • A new block in "a hook that returns a thenable, or doesn't wait for runTest()": the turn of then(), and a second thenable.
  • Debug build: vitest-flavor.test.ts 201 pass. bun_test.test.ts, done-async.test.ts, concurrent.test.ts, jest-hooks.test.ts, test-error-code-done-callback.test.ts and expect-deferred.test.ts: 98 pass. node-module-module.test.js: 80 pass. The new paths are clean with BUN_JSC_validateExceptionChecks=1.
  • Release build: test/js/bun/test/ 4196 pass, 1 fail. The failure is "error snapshots" in snapshot.test.ts, which expects a TTY and fails on the release build of main too. The release build of bun:test: call then() of a returned thenable before the microtask drain, and once for a fixture #47760 fails the 5 tests above.
  • Reference runs with Vitest 5.0.3: the turn of then() in aroundEach and aroundAll, the second thenable, and the two promises with no callable then give the results of this PR.

Other open PRs in this code

Self-review

20 concerns raised, 16 addressed:

  • One helper serves both sites, so the fixture site and the around hooks do not differ.
  • A value with no callable then keeps the result of await: a test pins both cases.
  • The route of an error that leaves a reaction is stated and pinned.
  • vi.waitFor is named with its reason.
  • The change is its own PR below bun:test: wait on a Promise subclass through its own then() #41008, so it does not wait for the decision there.

Not taken:


[human-review] gate passed · iteration 1 · 7 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/vitest-flavor.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/test/vitest-flavor.test.ts:
(pass) test context > a parameter is the context, never a done callback [369.32ms]
(pass) test context > is callable, and calling it like done() throws a deprecation error [345.12ms]
(pass) test context > TestRunner.getCurrentTest() in a concurrent test [337.61ms]
(pass) test context > TestRunner.getCurrentTest() is the task of the running test [356.91ms]
(pass) test context > is the same object in the hooks, the test, its retries and its repeats [350.23ms]
(pass) test context > task [291.80ms]
(pass) test context > onTestFinished and onTestFailed run after the hooks, last registered first [269.60ms]
(pass) test context > task.result is updated as the test runs [298.30ms]
(pass) test context > skip() [298.58ms]
(pass) test context > onTestFinished and onTestFailed of the context, in concurrent tests [287.40ms]
(pass) test context > annotate() records into task.annotations [211.59ms]
(pass) test context > onTestFinished and onTestF
... (truncated)

release without fix: 5 FAILED
bun test v1.4.4-canary.1 (de4f84628)

test/js/bun/test/vitest-flavor.test.ts:
(pass) test context > onTestFinished and onTestFailed outside of a test [27.31ms]
(pass) test context > skip() [32.25ms]
(pass) test context > is the same object in the hooks, the test, its retries and its repeats [41.67ms]
(pass) test context > TestRunner.getCurrentTest() in a concurrent test [46.74ms]
(pass) test context > a parameter is the context, never a done callback [63.41ms]
(pass) test context > TestRunner.getCurrentTest() is the task of the running test [48.53ms]
(pass) test context > onTestFinished and onTestFailed of the context, in concurrent tests [44.41ms]
(pass) test context > onTestFinished and onTestFailed run after the hooks, last registered first [45.88ms]
(pass) test context > task [49.40ms]
(pass) test context > task.result is updated as the test runs [51.31ms]
(pass) test context > annotate() records into task.annotations [47.12ms]
(pass) test context > is callable, and calling it like done() throws a deprecation error [57.54ms]
(pass) beforeAll and afterAll > do not run in a scope whose tests are all skipped [28.36ms]
(pass) test context > expect is bound to its te
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/vitest-flavor.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/test/vitest-flavor.test.ts:
(pass) test context > a parameter is the context, never a done callback [305.80ms]
(pass) test context > TestRunner.getCurrentTest() in a concurrent test [268.56ms]
(pass) test context > is callable, and calling it like done() throws a deprecation error [293.96ms]
(pass) test context > is the same object in the hooks, the test, its retries and its repeats [288.58ms]
(pass) test context > TestRunner.getCurrentTest() is the task of the running test [310.79ms]
(pass) test context > onTestFinished and onTestFailed of the context, in concurrent tests [207.72ms]
(pass) test context > task [267.53ms]
(pass) test context > task.result is updated as the test runs [276.42ms]
(pass) test context > onTestFinished and onTestFailed run after the hooks, last registered first [264.58ms]
(pass) test context > skip() [288.41ms]
(pass) test context > onTestFinished and onTestFailed outside of a test [293.26ms]
(pass) test context > annotate() record
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 893ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/421] build.rs build_script_build
[2/421] gen generated_host_exports.rs
generated_host_exports.rs: 119 exports (lazy=4, generic=115, rust=0); 288 extern-C blocks audited
[3/420] rustc bun_safety 
[4/420] rustc bun_zlib_sys 
[5/420] rustc bun_output 
[6/420] rustc bun_perf 
[7/420] rustc bun_boringssl_sys 
[8/420] rustc bun_brotli 
[9/420] rustc bun_zstd 
[10/420] rustc bun_ptr 
[11/420] rustc bun_picohttp 
[12/420] rustc bun_errno 
[13/420] rustc bun_cares_sys 
[14/420] rustc bun_base64 
[15/420] rustc bun_lsquic_sys 
[16/420] rustc bun_tcc_sys 
[17/420] rustc bun_clap 
[18/420] rustc bun_valkey 
[19/420] rustc bun_text_diff 
[20/420] rustc bun_paths 
[21/420] rustc bun_zlib 
[22/420] rustc bun_shell_parser 
[23/420] rustc bun_collections 
[24/420] rustc bun_url 
[25/420] rustc bun_which 
[26/420] rustc bun_semver 
[27/420] rustc bun_node_path 
[28/420] rustc bun_png 
[29/420] rustc bun_analytics 
[30/420] rustc bun_boringssl 
[31/420] rustc bun_threading 
[32/420] rustc bun_tar 
[33/420] rustc bun_sha_h
... (truncated)
diff hotspot
src/jsc/JSPromise.rs                             |  13 +++
 src/jsc/VirtualMachine.rs                        |  12 +--
 src/runtime/test_runner/bun_test.rs              |  11 +++
 src/runtime/test_runner/test_context.bind.ts     |  28 ++++--
 src/runtime/test_runner/test_context_around.rs   |  49 ++++------
 src/runtime/test_runner/test_context_fixtures.rs |  26 ++----
 test/js/bun/test/vitest-flavor.test.ts           | 114 +++++++++++++++++++++++
 7 files changed, 189 insertions(+), 64 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                              reads  edits  tests
src/jsc/JSPromise.rs                                  7      9    116
src/jsc/VirtualMachine.rs                             1      2    108
src/runtime/test_runner/bun_test.rs                  13     19    119
src/runtime/test_runner/test_context.bind.ts          1      1    107
src/runtime/test_runner/test_context_around.rs        2      0    111
src/runtime/test_runner/test_context_fixtures.rs      2      2    107
test/js/bun/test/vitest-flavor.test.ts                8     11    107

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:29 PM PT - Nov 2nd, 2026

❌ @robobun, your commit 3e35f7a has 40 failures in Build #137062 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47800

That installs a local version of the PR into your bun-47800 executable, so you can run:

bun-47800 --bun

@github-actions github-actions Bot added the claude label Nov 2, 2026
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on release builds of main (3f3a9ff) and of #47760, with small test files:

  • An async fixture function, or an async aroundEach or aroundAll hook, while Promise.prototype.then is not callable: Fixture "used" returned without calling use() and AroundHookSetupError. While then is replaced, the replacement is called once for each.
  • An aroundEach hook that returns a thenable: then() is called at once, and a second thenable is not followed. Vitest 5.0.3 calls it one microtask turn later and follows the second thenable.
  • On bun:test: call then() of a returned thenable before the microtask drain, and once for a fixture #47760, a builder fixture whose function returns a thenable gets the thenable itself while Promise.prototype.then is not callable.

The new tests in test/js/bun/test/vitest-flavor.test.ts fail on the release build of #47760 and pass on a debug build of this branch.

CI (build 137062): the red files are test/aot/* and test/bundler/compile-aot-size.test.ts. They are red on main too. No test of this PR is among them.

PR: #47800

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This review covers commit 53ca769, which is no longer the latest commit on this pull request; later commits are not covered by it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants